运行时
单例类 Runtime
中封装了罗技 G Hub 提供的 API 接口,实现了一些执行器在运行时所必需的核心功能,其中最核心的功能为“伪”中断处理机制。
“伪”中断机制
罗技 G Hub 在解释执行 Lua 代码时是单线程的,在采用轮询方式设计执行器模块时,会因为缺少中断机制而造成轮询的过程变得复杂化。例如,要想在传统的轮询方案中实现挂机中每隔 10 秒执行一次复活/回合重置、执行器随时启停等功能是十分麻烦的。随着集成工具功能不断增多,轮询的逻辑将变得越来越复杂且难于维护。因此,尽可能地在编程框架中引入中断机制是十分必要的。集成工具在执行器中引入了 Runtime
类,其中实现了基础的中断功能。
“伪”中断发生的条件
在使用罗技 G Hub 编程接口执行按键、点击操作时,都需要使用 Sleep
函数在每次操作后附加一定的延迟时间,因此,可以在 Sleep
的基础上封装一个新的 Runtime:sleep
函数作为统一的延时调用接口,所有的按键延时都通过 Runtime:sleep
来完成。调用 Runtime:sleep
后,首先执行中断处理函数,响应各类中断事件,在中断事件处理完毕后,再执行实际的睡眠操作。如果你有一定的操作系统基础知识,上述中断处理的流程应该很好理解。
“伪”中断的实现
为了让中断发生的更为频繁(即确保 Runtime
在执行 Runtime:sleep
期间保持对执行器的控制权),可以将整个睡眠的时间分割为若干个更短的时间。有关 Runtime:sleep
的详细使用方法及实现细节,请参见 sleep
小节。Runtime:sleep
将比 Sleep
产生稍大一些的开销,但这样的设计大大简化了执行器轮询方案设计,加之控制器本身具备精准判定游戏内状态并下达正确命令的能力,因此这样额外的开销是值得的。
下面是一个简单的使用 Runtime
和 Interrupt
的例子。
PATH = "C:/Users/Silver/Games/CSOL-Utilities/Executor/"
---加载 LUA 源文件。
---@param file_name string 文件名(相对路径)。
---@return nil
function Include(file_name)
if (type(file_name) == "string")
then
dofile(PATH .. file_name)
end
end
中断现场
在操作系统中,中断发生时,需要为当前执行流保存中断现场,然后根据需要切换至另一个执行流,这是操作系统中多任务调度的基础。集成工具也提供了类似的功能,不过,在开发工作中,中断现场保存功能几乎用不到,这主要和集成工具一次只执行一项功能有关,即除了中断处理外,只存在一条执行流,并不存在执行流并发的情况,故本节对此部分内容暂时不予详细介绍。
字段
本小节介绍 Runtime
中定义的默认字段,Lua 语法特性允许你在任何时候根据自己需要向其中新增自定义字段。例如,当需要在中断回调函数中使用一些“静态”的变量时,可以将这些变量添加到 Runtime
中。
interrupt_mask_flag
说明:中断屏蔽标志位,类型为 boolean
。若设置为 true
则屏蔽中断(关中断);否则,允许中断(开中断)。
注解:此字段不应该手动修改,而应该通过 enable_interrupt
和 disable_interrupt
修改。
interrupt_busy_flag
说明:中断忙标志位,类型为 boolean
。用于标识当前是否正在进行中断处理,防止发生中断嵌套。
注解:此字段值在运行时自动调整,不应该以任何方式修改。
interrupts
说明:中断列表,类型为 Interrupt[]
。
注解:此字段不应手动修改,而应该使用 register_interrupt
和 unregister_interrupt
进行中断处理函数的注册和注销。
interrupt_mode
说明:中断模式。
注解:此字段应通过 set_interrupt_mode
修改。
方法
sleep
说明:挂起当前执行流指定时间。
原型:Runtime:sleep(milliseconds, precise)
milliseconds
:挂起时间,类型为integer
。precise
:是否需要精确定时,类型为boolean | nil
。
注解:挂起时,会将 milliseconds
分割为若干更短的时间片,并按照时间片为单位进行休眠。当 precise
置为 false
或 nil
(缺省值)时,不保证挂起时间的精确度,这适用于对挂起时间精度要求不高的场景;当 precise
置为 true
或其他真值时,将尽力保证挂起时间精确,但这样做会带来额外的性能开销。
实际场景下,受操作系统线程调度策略、电源管理策略等因素的影响,实际休眠时间会存在较大的波动。针对这种情况,更好的方案是采用自适应的方式计算阈值。在执行器提供的 sleep
实现中,选取一个小一些的休眠时间片(10 ms)作为默认的休眠时间片,然后,根据过去执行 Sleep(10)
的实际时间开销的历史情况,动态预测本次执行 Sleep(10)
可能的时间开销,得到预测值 \(t\),按照休眠时间片为单位进行休眠。如果剩余休眠时间大于 \(\tau\),则执行 Sleep(10)
,计算出实际休眠时间 \(t\),然后更新预测值 \(\tau\);否则,若 precise
置为 true
就执行忙等(precise
置为 false
时直接忽略精度,按照剩余时间直接休眠),且保持 \(\tau\) 和 \(t\) 不变。更新 \(\tau\) 时,采用指数平均法(exponential average),计算方式如下:
\[\tau_{n+1} = \alpha\tau_{n} + \left(1 - \alpha\right)t_{n}.\]
其中,\(\alpha\) 为预测值和实际值的权重,在执行器提供的 sleep
实现中,认为二者同等重要,即取 \(\alpha = 0.5\)。
注意,若中断处理函数执行时间过长,即便指定
precise
为true
也无法保证挂起时间精度。因此,若休眠时间很短,且对休眠时间精度有特别要求,则在设计中断处理函数时,需要谨慎考虑其时间消耗。
下面给出了 sleep
的具体实现:
Runtime.expected_sleep_time = 10 -- 根据最近睡眠情况推测的一轮睡眠时间
Runtime.actual_sleep_time = 10 -- 最近一次实际的一轮睡眠时间
---挂起当前执行流,挂起后,可以处理中断事件。除了 `Runtime` 内部方法外,其他地方都应当调用 `Runtime:sleep`,而非直接调用罗技 API 中的 Sleep,这样可以进行中断处理。
---@param milliseconds integer 挂起的时间。
---@param precise boolean | nil 是否需要尽力保证精度。
---@return nil
function Runtime:sleep(milliseconds, precise)
-- 罗技 API 不支持真正的中断,故而当某个过程主动将自己挂起时(即调用Runtime:sleep)视为自发中断,此时可以处理外部事件
local before_int = Runtime:get_running_time()
-- 先执行中断处理
self:interrupt()
-- 中断处理结束后,再校验参数(无论如何都要进行中断处理,即便参数非法)
milliseconds = math.floor(milliseconds) -- 防止提供小数时间,若类型非 `number` 会返回 `nil`
if (type(milliseconds) ~= "number" or milliseconds < 0)
then
return
end
-- 预测下一次休眠会消耗的时间
local expected_sleep_time = 0.5 * self.expected_sleep_time + 0.5 * self.actual_sleep_time
local after_int = Runtime:get_running_time()
local int_time = after_int - before_int
milliseconds = milliseconds - int_time -- 去除中断处理耗时
-- 将长时间的休眠拆分为若干短时间休眠,确保 `Runtime` 常常保持对程序的控制权
while (milliseconds > 0)
do
if (milliseconds > expected_sleep_time) -- 大于预计休眠时间
then
local start_timepoint = Runtime:get_running_time()
(10) -- 按照 10 ms 时间片大小进行休眠
Sleeplocal end_timepoint = Runtime:get_running_time()
local real_sleep_time = end_timepoint - start_timepoint
milliseconds = milliseconds - real_sleep_time -- 减去实际睡眠时间
-- 更新预测值和实际值
self.expected_sleep_time, self.actual_sleep_time = expected_sleep_time, real_sleep_time
else -- 0 < milliseconds ≤ expected_sleep_time
if (precise) -- 需要较高精度,剩余时间采用忙等
then
local begin = Runtime:get_running_time()
repeat
until Runtime:get_running_time() - begin >= milliseconds -- 忙等以确保精度符合要求
else
(milliseconds) -- 不考虑精度,按照剩余时间直接休眠
Sleepend
break
end
end
end
下面是一个简单的对比示例:
PATH = "C:/Users/Silver/Develop/CSOL-Utilities/source/Executor/"
---加载 LUA 源文件。
---@param file_name string 文件名(相对路径)。
---@return nil
function Include(file_name)
if (type(file_name) == "string")
then
dofile(PATH .. file_name)
end
end
("Runtime.lua")
Include("Console.lua")
Includelocal i = 25
repeat
local _s = Runtime:get_running_time()
-- Runtime:sleep(100) -- `precise` 缺省,取为 `nil`
-- Runtime:sleep(100, true) -- `precise` 设置为 `true`
local _e = Runtime:get_running_time()
Console:information("耗时:%d", _e - _s)
i = i - 1
until i == 0
register_interrupt
说明:注册中断。
原型:Runtime:register_interrupt(init)
init
:中断对象或中断处理回调函数,类型为Interrupt | function
。
返回:中断标识符,类型为 integer
,有效的标识符为大于 0
的整数。
注解:若 init
不是 function
类型,返回为 0
。
unregister_interrupt
说明:注销中断。
原型:Runtime:unregister_interrupt(id)
id
:中断标识符,类型为integer
。此值为调用register_interrupt
时的返回值。
返回:若操作成功,返回 true
;否则,返回 false
。
enable_interrupt
说明:开中断,调用后允许中断。
原型:Runtime:enable_interrupt()
注解:强烈建议结合 push_interrupt_mask_flag
和 pop_interrupt_mask_flag
使用。
disable_interrupt_flag
说明:关中断,调用后屏蔽中断。
注解:对不可屏蔽中断(即 Interrupt.maskable
为 false
),屏蔽不会生效。
原型:Runtime:disable_interrupt()
注解:强烈建议结合 push_interrupt_mask_flag
和 pop_interrupt_mask_flag
使用。
push_interrupt_mask_flag
说明:在栈中保存中断屏蔽标志位。
原型:Runtime:push_interrupt_mask_flag()
pop_interrupt_mask_flag
说明:从栈中恢复最近一次保存的中断屏蔽标志位。
原型:Runtime:pop_interrupt_mask_flag()
set_interrupt_mode
说明:设置中断模式。
原型:Runtime:set_interrupt_mode(mode)
mode
:中断模式,可取下列值:
字段 | 值 | 备注 |
---|---|---|
Runtime.INTERRUPT_BURST_MODE | 0 | 猝发模式 |
Runtime.INTERRUPT_SEQUENCE_MODE | 1 | 顺序模式 |
Runtime.INTERRUPT_RANDOM_MODE | 2 | 随机模式 |
注解:猝发模式是指每次中断时刻(即每一轮睡眠开始时)到来后,触发并处理所有注册的中断;顺序模式是指每次中断时刻到来后,按注册顺序处理下一个中断;随机模式是指每次中断时刻到来后,随机触发并处理一个中断。默认的中断模式为猝发式。猝发模式对中断事件响应最快,但运行时开销稍大(对于只注册少量中断的情形,这种开销可以忽略)。
get_running_time
说明:获取当前运行时间。
返回值:当前运行时间,类型为 integer
,单位为毫秒。
示例
中断注册、注销
引入中断处理后,可以方便地处理一些与当前执行流无关的事件。为了将中断处理逻辑与中断回调逻辑解耦,通常的做法是提供中断注册和注销的接口。在注册中断后,每次执行 Runtime:interrupt
都会依次执行各个注册好的中断对象提供的回调函数。关于如何创建中断对象,请参阅 中断。
例如,要想实现集成工具中提供的随时启停功能,只需要按下面的方式注册中断回调函数:
---手动接管标志,防止重复打印消息。
Runtime.manual_flag = false
---注册暂停事件处理函数,处理用户手动接管事件。
Runtime:register_interrupt(
function ()
if (Keyboard:is_modifier_pressed(Keyboard.LCTRL) and Keyboard:is_modifier_pressed(Keyboard.RCTRL))
then
Keyboard:reset()
Mouse:reset()
if (not Runtime.manual_flag)
then
Console:information("开始手动接管,禁用键鼠动作。")
Keyboard:freeze()
Mouse:freeze()
end
Runtime.manual_flag = true
elseif (Keyboard:is_modifier_pressed(Keyboard.LALT) and Keyboard:is_modifier_pressed(Keyboard.RALT))
then
if (Runtime.manual_flag)
then
Console:information("中止手动接管,允许键鼠动作。")
Keyboard:unfreeze()
Mouse:unfreeze()
end
Runtime.manual_flag = false
end
end
)
按下左右 CTRL
后,通过调用 Keyboard
和 Mouse
提供的 freeze
冻结键盘和鼠标,达到暂停所有操作的效果;按下左右 ALT
后,通过调用 Keyboard
和 Mouse
提供的 freeze
解冻键盘和鼠标,恢复键鼠操作。
需要指出,Runtime
类实现的中断功能并非真正意义上的中断功能,当前执行流调用 Runtime:sleep
后,即表示其“出让执行权给其他执行流”,此处的“其他执行流”即中断处理回调函数,因此,我将 Runtime
类实现的中断功能称作“伪”中断功能。
原子操作
通过暂时关闭中断,您可以完成原子操作。由于集成工具的核心功能均围绕中断机制实现,因此除非必要(如 Weapon
类中实现的购买方法就是原子操作),不应该关中断。
Runtime:push_interrupt_mask_flag() -- 保存当前中断屏蔽标志位
Runtime:disable_interrupt() -- 关中断
Keyboard:click_several_times(Keyboard.ESCAPE, 10) -- 按下 10 次 ESC 键,且不被中断
Runtime:pop_interrupt_mask_flag() -- 恢复中断屏蔽标志位